﻿using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using System.Reflection;
using System.Reflection.Emit;
using NMapper.Mappings;
using NMapper.Utils;

namespace NMapper
{
    public class Mapper<TDatabase, TDatabaseEntity, TOutput> : IMapper<TDatabase, TDatabaseEntity, TOutput>
        where TOutput : class
        where TDatabase : IDisposable 
    {
        protected MapperFactory<TDatabase> Mappers { get; private set; }

        private Type transientSelectorLambdaType;
        private MethodInfo transientSelectMethod;
        private MethodInfo transientCastMethod;
        private MethodInfo transientToArrayMethod;
        private ISelectors<TDatabase> primaryKey;
        
        private List<IMapping<TDatabase>> mappings = new List<IMapping<TDatabase>>();
        private MapFactory<TDatabase, TDatabaseEntity, TOutput>  map;
        private bool isInitialized;
        private Type transientType;
        private MappingContext<TDatabase> context;

        public Mapper(MappingContext<TDatabase> context) 
        {
            Mappers = new MapperFactory<TDatabase>(context);
            map = new MapFactory<TDatabase, TDatabaseEntity, TOutput>(this, context);
            this.context = context;
        }

        public MappingContext<TDatabase> Context
        {
            get { return context; }
        }

        protected virtual void Define()
        {
        }

        public void Initialize()
        {
            // Singleton strategies are thread-safe, though this code admitedly does not appear to be.  However, 
            // `Initialize` will only be called one time when used as a singleton via Ninject, but will be called 
            // automatically when created on-the-fly in a parent mapper.
            if (isInitialized)
                return;

            Define();
            isInitialized = true;

            var assembly = AppDomain.CurrentDomain.DefineDynamicAssembly(new AssemblyName(Guid.NewGuid().ToString()), AssemblyBuilderAccess.Run); 
            var module = assembly.DefineDynamicModule(assembly.GetName().Name + "Module");
            var type = DefineTransientType(module);
            transientType = type.CreateType();
            Compile();
        }

/*
        public ValueMapping<TDatabaseEntity, TOutput, TDbValue, TOutputValue> SetPrimaryKey<TDbValue, TOutputValue>(Func<TDatabaseEntity, TDbValue> dbSelector, Func<TOutput, TOutputValue> outputSelector)
        {
            var key = new ValueMapping<TDatabaseEntity, TOutput, TDbValue, TOutputValue>(dbSelector, outputSelector);
            PrimaryKey = key;
            return key;
        }
*/

        public ISelectors<TDatabase> PrimaryKey
        {
            get { return primaryKey; }
            set { primaryKey = value; }
        }

        public IDiscriminatorMapping<TDatabase> Discriminator { get; set; }

        public Type TransientType
        {
            get { return transientType; }
        }

        public MapFactory<TDatabase, TDatabaseEntity, TOutput> Map
        {
            get { return map; }
        }

        public void Add(IMapping<TDatabase> mapping)
        {
            if (isInitialized)
                throw new InvalidOperationException("Cannot add new mappings when mapper is already initialized.");
            mappings.Add(mapping);
        }

        private TypeBuilder DefineTransientType(ModuleBuilder module)
        {
            var fullName = typeof(TDatabaseEntity).Name + "_To_" + typeof(TOutput).Name;
            var typeBuilder = module.DefineType(fullName);
            foreach (var mapping in mappings)
            {
                mapping.DefineTransientType(module, typeBuilder);
            }
            return typeBuilder;
        }

        private void Compile()
        {
            // Cache some static data
            transientSelectorLambdaType = typeof(Func<,>).MakeGenericType(typeof(TDatabaseEntity), TransientType);
            transientSelectMethod = typeof(Queryable).GetGenericMethod(
                "Select", 
                new[] { typeof(TDatabaseEntity), TransientType }, 
                new[] { typeof(IQueryable<TDatabaseEntity>), typeof(Expression<>).MakeGenericType(transientSelectorLambdaType) }
            ); 
            transientCastMethod = typeof(Queryable).GetMethod("Cast").MakeGenericMethod(TransientType);                 
            transientToArrayMethod = typeof(Enumerable).GetMethod("ToArray").MakeGenericMethod(TransientType); 
        }

        public LambdaExpression CreateTransientSelector(MapContext<TDatabase> mapContext)
        {
            var x = Expression.Parameter(typeof(TDatabaseEntity));
            var initializers = new List<MemberBinding>();

            foreach (var mapping in mappings) 
            {
                mapping.DefineTransientInitialization(mapContext, TransientType, initializers, x);
            }

            var body = Expression.MemberInit(Expression.New(TransientType), initializers);
            return Expression.Lambda(transientSelectorLambdaType, body, x);
        }

        private Expression<Func<TDatabaseEntity, bool>> CreatePrimaryKeyFilter(MapContext<TDatabase> mapContext, object pk)
        {
            var mc = Expression.Constant(mapContext, typeof(MapContext<TDatabase>));
            var x = Expression.Parameter(typeof(TDatabaseEntity));

            var binder = new LambdaBinder();
            var primaryKeySelector = binder.BindBody(PrimaryKey.DbSelectorExpression, mc, x);

            var body = Expression.Equal(primaryKeySelector, Expression.Constant(pk));
            return Expression.Lambda<Func<TDatabaseEntity, bool>>(body, x);
        }

        public Array TransformToTransient(MapContext<TDatabase> mapContext, TDatabase db, IEnumerable<TDatabaseEntity> dbResult)
        {
            var queryable = transientSelectMethod.Invoke(null, new object[] { dbResult, CreateTransientSelector(mapContext) });
            queryable = transientCastMethod.Invoke(null, new[] { queryable });
            Array array = (Array)transientToArrayMethod.Invoke(null, new[] { queryable });
            return array;
        }

        public void OnTransientInitialized(MapContext<TDatabase> mapContext, Array array)
        {
            foreach (var mapping in mappings)
            {
                mapping.OnTransientInitialized(mapContext, array);
            }            
        }

        object[] IMapper<TDatabase>.TransformToOutput(MapContext<TDatabase> mapContext, Array transient)
        {
            return TransformToOutput(mapContext, transient).Cast<object>().ToArray();
        }

        public TOutput[] TransformToOutput(MapContext<TDatabase> mapContext, Array transient)
        {
            var result = new List<TOutput>();
            try
            {
                foreach (var item in transient)
                {
                    TOutput outputItem = null;
                    Type outputType;
                    if (Discriminator != null)
                    {
                        var value = Discriminator.GetValue(item);
                        var mapping = Discriminator[value];
                        outputType = mapping.OutputType;
                    }
                    else
                    {
                        outputType = typeof(TOutput);
                    }
                    if (PrimaryKey is IDbValueMapping<TDatabase>)
                    {
                        var key = ((IDbValueMapping<TDatabase>)PrimaryKey).TransientProperty.GetValue(item, null);
                        outputItem = (TOutput)mapContext.GetFromCache(outputType, key);
                    }
                    if (outputItem == null) 
                    {
                        outputItem = (TOutput)Activator.CreateInstance(outputType);
                    }
                    if (PrimaryKey is IDbValueMapping<TDatabase>)
                    {
                        var key = ((IDbValueMapping<TDatabase>)PrimaryKey).TransientProperty.GetValue(item, null);
                        mapContext.Cache(outputType, key, outputItem);
                    }

                    MapToOutput(mapContext, item, outputItem);

                    result.Add(outputItem);
                }
            }
            catch (Exception e)
            {
                throw new InvalidOperationException(this + ": Error transforming transient into output.", e);
            }
            return result.ToArray();
        }

        void IMapper<TDatabase>.MapToOutput(MapContext<TDatabase> mapContext, object transient, object output)
        {
            MapToOutput(mapContext, transient, (TOutput)output);
        }

        public void MapToOutput(MapContext<TDatabase> mapContext, object transient, TOutput output)
        {
            foreach (var mapping in mappings)
            {
                mapping.MapToOutput(mapContext, transient, output);
            }
        }

        object[] IMapper<TDatabase>.TransformToInput(MapContext<TDatabase> mapContext, object[] output, Func<int[], IEnumerable<object>> fetch)
        {
            return TransformToInput(mapContext, output.Cast<TOutput>().ToArray(), ids => fetch(ids).Cast<TDatabaseEntity>()).Cast<object>().ToArray();
        }

        public TDatabaseEntity[] TransformToInput(MapContext<TDatabase> mapContext, TOutput[] output, Func<int[], IEnumerable<TDatabaseEntity>> fetch)
        {
            IEnumerable<TDatabaseEntity> inserted;
            return TransformToInput(mapContext, output, fetch, out inserted);
        }

        public TDatabaseEntity[] TransformToInput(MapContext<TDatabase> mapContext, TOutput[] output, Func<int[], IEnumerable<TDatabaseEntity>> fetch, out IEnumerable<TDatabaseEntity> inserted)
        {
            if (primaryKey == null)
                throw new InvalidOperationException("Primary Key not defined for " + GetType());

            int[] ids = output.Select(x => (int)primaryKey.OutputSelector(x)).ToArray();
            var existingEntities = fetch(ids);
            var existingEntitiesById = existingEntities.ToDictionary(x => primaryKey.DbSelector(mapContext, x), x => x);

            var items = output.Select(x => new { Output = x, OutputId = primaryKey.OutputSelector(x) });

            List<TDatabaseEntity> insertedList = new List<TDatabaseEntity>();
            List<TDatabaseEntity> result = new List<TDatabaseEntity>();

            foreach (var item in items)
            {
                var itemOutput = item.Output;
                TDatabaseEntity itemDb;
                if (!existingEntitiesById.TryGetValue(item.OutputId, out itemDb))
                {
                    itemDb = Activator.CreateInstance<TDatabaseEntity>();
                    insertedList.Add(itemDb);
                    mapContext.AfterMapToInput += () => PrimaryKey.OutputSelectorExpression.SetProperty(itemOutput, PrimaryKey.DbSelector(mapContext, itemDb));
                }
                result.Add(itemDb);

                MapToInput(mapContext, itemOutput, itemDb);
            }

            inserted = insertedList;
            return result.ToArray();
        }

        public void MapToInput(MapContext<TDatabase> mapContext, object output, object input)
        {
            MapToInput(mapContext, (TOutput)output, (TDatabaseEntity)input);
        }

        public void MapToInput(MapContext<TDatabase> mapContext, TOutput output, TDatabaseEntity input)
        {
            foreach (var mapping in mappings)
            {
                try
                {
                    mapping.MapToInput(mapContext, output, input);
                }
                catch (Exception e)
                {
                    throw new InvalidOperationException(string.Format("Error mapping {0} to {1} using {2}", output, input, mapping), e);
                }
            }                            
        }

        public override string ToString()
        {
            return string.Format("Mapper<{0}, {1}, {2}>", typeof(TDatabase).Name, typeof(TDatabaseEntity).Name, typeof(TOutput));
        }

        public TOutput[] MapQuery(DatabaseAdapter<TDatabase> dbAdapter, Func<TDatabase, IQueryable<TDatabaseEntity>> fetch, out int count, int pageIndex = 0, int pageSize = -1, object arguments = null)
        {
            return MapQuery(dbAdapter, fetch, null, out count, pageIndex, pageSize);
        }

        public TOutput[] MapQuery(DatabaseAdapter<TDatabase> dbAdapter, Func<TDatabase, IQueryable<TDatabaseEntity>> fetch, object arguments = null)
        {
            int count;
            return MapQuery(dbAdapter, fetch, arguments, out count, 0, -1);
        }

        private TOutput[] MapQuery(DatabaseAdapter<TDatabase> dbAdapter, Func<TDatabase, IQueryable<TDatabaseEntity>> fetch, object arguments, out int count, int pageIndex, int pageSize)
        {
            var mapContext = new MapContext<TDatabase>(context, dbAdapter, arguments);

            Array transient;
            using (var dbInstance = dbAdapter.CreateDatabase())
            {
                var db = dbInstance.Db;
                var dbResult = fetch(db);

                // Need this to make the compiler happy, even though this value will never be consumed
                count = 0;

                // When we are using pagination...
                if (pageSize != -1) 
                {
                    // ...the count must be queried separately
                    count = dbResult.Count();

                    //...we skip and take based on the pageIndex (zero-based) and the pageSize
                    dbResult = dbResult.Skip(pageIndex * pageSize).Take(pageSize);
                }
                
                transient = TransformToTransient(mapContext, db, dbResult);

                // When no pagination is involved the count is always equal to the number of records in the returned array.
                if (pageSize == -1)
                    count = transient.Length;
            }
            OnTransientInitialized(mapContext, transient);
            mapContext.OnAfterTransientInitialization();
            var output = TransformToOutput(mapContext, transient);
            return output;
        }

        public TDatabaseEntity[] MapQueryReverse(DatabaseAdapter<TDatabase> dbAdapter, TOutput[] output, Func<TDatabase, int[], IQueryable<TDatabaseEntity>> fetch, object arguments = null)
        {
            using (var dbInstance = dbAdapter.CreateDatabase())
            {
                var db = dbInstance.Db;
                var mapContext = new MapContext<TDatabase>(context, dbAdapter, arguments);
                IEnumerable<TDatabaseEntity> inserted;
                var result = TransformToInput(mapContext, output, ids => fetch(db, ids), out inserted);
                dbAdapter.Insert(db, inserted);
                dbAdapter.Commit(db);

                mapContext.OnAfterMapToInput();

                return result;                
            }
        }

        public TDatabaseEntity Fetch(DatabaseAdapter<TDatabase> dbAdapter, TDatabase db, TOutput output, object arguments = null)
        {
            var id = PrimaryKey.OutputSelector(output);
            var table = dbAdapter.GetTable<TDatabaseEntity>(db);
            var mapContext = new MapContext<TDatabase>(context, dbAdapter, arguments);
            return table.Single(CreatePrimaryKeyFilter(mapContext, id));
        }

        public void Commit(DatabaseAdapter<TDatabase> dbAdapter, TOutput output, Action<TDatabaseEntity> committer, object arguments = null)
        {
            var id = PrimaryKey.OutputSelector(output);
            using (var dbInstance = dbAdapter.CreateDatabase())
            {
                var db = dbInstance.Db;
                var table = dbAdapter.GetTable<TDatabaseEntity>(db);
                var mapContext = new MapContext<TDatabase>(context, dbAdapter, arguments);
                var dbEntity = table.Single(CreatePrimaryKeyFilter(mapContext, id));
                committer(dbEntity);
                dbAdapter.Commit(db);
            }
        }

        public Type OutputType
        {
            get { return typeof(TOutput); }
        }
    }
}